// Copyright 2012 Google Inc. All Rights Reserved.

package com.google.appengine.syncserver.entities;

import com.google.appengine.api.datastore.Entity;
import com.google.appengine.api.datastore.Text;
import com.google.appengine.syncserver.entities.EntityKeyFactory.PreferencesEntityKind;

import org.codehaus.jackson.JsonGenerationException;
import org.codehaus.jackson.JsonParseException;
import org.codehaus.jackson.map.JsonMappingException;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.map.type.MapType;
import org.codehaus.jackson.map.type.TypeFactory;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.SortedMap;
import java.util.TreeMap;

/**
 * Sharded preferences for storing key-value pairs in the datastore.
 */
public class ShardedPreferences {

  // Variables to pretty print.
  private static final String TABLE_BEGIN = "<table border=\"1\">\n";
  private static final String TABLE_ROW = "<tr> <td> %s </td> <td> %s </td> <td> %d </td> <td> " +
      "%s </td> </tr> \n";
  private static final String TABLE_END = "</table> \n";
  private static final Object TABLE_COLUMNS = "<tr> <td> Key </td> <td> Value </td> <td> Version " +
      "</td> <td> Deleted </td> </tr> \n";

  // Cache of properties from the entities. Populated from datastore.
  private final Map<String, PreferencesValue> properties;

  private static final int SHARD_LENGTH = 950000;

  private static final String PREFERENCE_SHARD_DATA_KEY = "preference_data";
  private static final String PREFERENCE_SHARD_NUMBER_KEY = "preference_shard_number";
  // Current max update version number.
  private long maxVersion;

  /**
   * @param shards The entities which for the preference.
   * @param maxVersion
   */
  public ShardedPreferences(Collection<Entity> shards, long maxVersion)
      throws JsonParseException, JsonMappingException, IOException {
    properties = getMergedMap(shards);
    this.maxVersion = maxVersion;
  }

  /**
   * Clear all properties
   */
  public void clear() {
    properties.clear();
  }

  /**
   * Get the current maximum version..
   *
   * @return Get maximum version in the sharded preferences.
   */
  public long getMaxVersion() {
    return maxVersion;
  }

  /**
   * Get properties which have update version greater than version.
   * In case of paging we want to limit ourself to certain size therefore
   * we use approxSize for that. The map returned is always non-empty.
   *
   * @param version
   * @param approxSize approximate size of the map which need to be returned.
   * @return Returns a map whose entries have an update version greater than
   *         the value.
   */
  public Map<String, PreferencesValue> getProperties(long version, int approxSize) {
    Map<String, PreferencesValue> returnMap = new HashMap<String, PreferencesValue>();

    // Current size of the return value.
    int size = 0;
    for (Entry<String, PreferencesValue> entry : getValues(version)) {
      size += getSize(entry);
      if (size >= approxSize && !returnMap.isEmpty()) {
        // If the size is greater than approxSize then we are done constructing
        // the map.
        return returnMap;
      }

      returnMap.put(entry.getKey(), entry.getValue());
    }
    return returnMap;
  }

  /**
   * Get the property corresponding to the key.
   *
   * @param key
   * @return Property corresponding to the key.
   */
  public PreferencesValue getProperty(String key) {
    return properties.get(key);
  }

  /**
   * Html String.
   */
  public String toHtmlString() {
    StringBuilder sb = new StringBuilder(TABLE_BEGIN);
    sb.append(TABLE_COLUMNS);
    for (Entry<String, PreferencesValue> entry : properties.entrySet()) {
      PreferencesValue value = entry.getValue();
      sb.append(String.format(TABLE_ROW, entry.getKey(), value.getValue(), value.getVersion(),
          String.valueOf(value.getDeleted())));
    }
    sb.append(TABLE_END);
    return sb.toString();
  }

  /**
   * shard properties into smaller maps so that each map fits into
   * the 1 MB/entity limit.
   *
   * @return An arrayList of the property maps.
   */
  public List<Entity> getEntities(String username, PreferencesEntityKind preferencesKind,
      String preferencesName) throws JsonGenerationException, JsonMappingException, IOException {

    List<Entity> returnList = new ArrayList<Entity>();

    String serializedString = getSerializedString(properties);
    int shardNo = 0;
    for (int i = 0; i < serializedString.length(); i += SHARD_LENGTH) {
      Entity propertyShard = new Entity(EntityKeyFactory.getShardedEntityKey(username,
          preferencesKind, preferencesName, shardNo));
      shardNo++;
      int strEnd = Math.min(i + SHARD_LENGTH, serializedString.length());
      propertyShard.setUnindexedProperty(PREFERENCE_SHARD_DATA_KEY, new Text(serializedString
          .substring(i, strEnd)));
      propertyShard.setUnindexedProperty(PREFERENCE_SHARD_NUMBER_KEY, new Integer(shardNo));
      returnList.add(propertyShard);
    }

    return returnList;
  }

  /**
   * Remove a property.
   *
   * @param key
   */
  public void removeProperty(String key) {
    if (!properties.containsKey(key)) {
      return;
    }
    maxVersion++;
    properties.get(key).setAsDelete(maxVersion);
  }

  /**
   * Set a sharded preference property.
   */
  public void setProperty(String key, PreferencesValue value) {
    // Increment the maxVersion in the metadata.
    maxVersion++;

    if (value == null) {
      value = new PreferencesValue(null);
      value.setAsDelete(maxVersion);
    } else {
      value.setVersion(maxVersion);
    }
    properties.put(key, value);
  }

  /**
   * Get map of properties over all entities.
   * @param entityList The list of entities.
   * @return Map of all properties.
   */
  private Map<String, PreferencesValue> getMergedMap(Collection<Entity> entityList)
      throws JsonParseException, JsonMappingException, IOException {
    SortedMap<Long, Entity> shardedStrings = new TreeMap<Long, Entity>();
    if (entityList.isEmpty()) {
      return new HashMap<String, PreferencesValue>();
    }

    int size = 0;
    for (Entity entity : entityList) {
      shardedStrings.put((Long) entity.getProperty(PREFERENCE_SHARD_NUMBER_KEY), entity);
    }

    StringBuffer strBuffer  = new StringBuffer(size);
    for (Entry<Long, Entity> entry : shardedStrings.entrySet()) {
      strBuffer.append(((Text) entry.getValue().getProperty(PREFERENCE_SHARD_DATA_KEY)).getValue());
    }

    return getProperties(strBuffer.toString());
  }

  private int getSize(Entry<String, PreferencesValue> entry) {
    return getSize(entry.getKey()) + getSize(entry.getValue().toString());
  }

  private int getSize(String value) {
    // Each char in a java string occupies two bytes.
    return 2 * value.length();
  }

  /**
   * Get all entries in the property set which have been updated after a
   * particular version sorted by version .
   *
   * @param version
   * @return An Collection of key-value pair with version numbers greater than the
   *         version, sorted by version numbers.
   */
  private Collection<Entry<String, PreferencesValue>> getValues(long version) {

    // We need to sort by version number hence we use a TreeMap so that we can
    // order key-value pairs in the shared preferences.
    Map<Long, Entry<String, PreferencesValue>> orderedMap =
        new TreeMap<Long, Entry<String, PreferencesValue>>();

    for (Entry<String, PreferencesValue> entry : properties.entrySet()) {
      PreferencesValue propertyValue = entry.getValue();
      // We want entries >= the version.
      if (propertyValue.getVersion() < version) {
        continue;
      }
      orderedMap.put(propertyValue.getVersion(), entry);
    }

    return orderedMap.values();
  }

  /**
   * Get all properties.
   */
  public Map<String, PreferencesValue> getAllProperties() {
    return new HashMap<String, PreferencesValue>(properties);
  }

  private static String getSerializedString(Map<String, PreferencesValue> preferenceMap)
      throws JsonGenerationException, JsonMappingException, IOException {

    return new ObjectMapper().writeValueAsString(preferenceMap);
  }

  /**
   * Deserialize json into properties.
   */
  private static Map<String, PreferencesValue> getProperties(String str)
      throws JsonParseException, JsonMappingException, IOException {
    if (str.isEmpty()) {
      return new HashMap<String, PreferencesValue>();
    }

    return new ObjectMapper().readValue(str, getMapType());
  }

  private static MapType getMapType() {
    return TypeFactory.defaultInstance().constructMapType(HashMap.class, String.class,
        PreferencesValue.class);
  }
}
